<?php

namespace StudyBuddy;

/**
 * @todo handle various MongoExceptions by wrapping
 * methods in try/catch blocks and add logging
 * to log errors
 */
class MongoCache implements Interfaces\Cache {

    /**
     * Mongo object
     * @var object of type Mongo
     */
    protected $oMongo;

    /**
     * MongoCollection collection that
     * is used for storing cache items
     *
     * @var object of type MongoCollection
     */
    protected $_collection;

    /**
     * Special tag that will be
     * prepended to every item key
     * Default is empty string.
     *
     * @var string
     */
    protected $nameSpace = '';

    /**
     * Flag indicates to compress data
     * this will save storage space if
     * values are fairly long strings
     * and if value is close to 4MB (limit of mongo document size)
     * then you should use this option
     *
     * @var bool
     */
    protected $bCompress = false;

    public static function factory(Registry $oRegistry) {
        $oIni = $oRegistry->Ini;
        $aConfig = $oIni->getSection('CACHE_MONGO');
        d('cp');
        $oMongo = $oRegistry->Mongo->getMongo();

        $o = new self($oMongo, $aConfig['db'], $aConfig['collection']);

        return $o;
    }

    /**
     * Constructor
     *
     * @param string $server connection string
     * (may contain username/password)
     * like this:
     *
     * @param string $db name of database
     *
     * @param string $collection name of collection
     */
    public function __construct(\Mongo $oMongo, $db, $collection, $nameSpace = null, $compress = false) {

        if (!extension_loaded('mongo')) {
            throw new \LogicException('The MongoDB extension must be loaded for using this backend !');
        }

        $this->nameSpace = $nameSpace;

        if (true === $compress) {
            if (!function_exists('gzdeflate')) {
                throw new \LogicException('gzdeflate function not available. Purhaps you php was compiled without the gzip support');
            }

            $this->bCompress = (bool) $compress;
        }

        $this->oMongo = $oMongo;
        $this->_collection = $oMongo->selectCollection($db, $collection); //$this->_db->selectCollection($collection);
        $this->_collection->ensureIndex(array('tags' => 1));
    }

    /**
     * Test if a cache is available or not (for the given id)
     *
     * @param  string $id Cache id
     * @return mixed|false (a cache is not available) or "last modified" timestamp (int) of the available cache record
     */
    public function test($key) {
        $cursor = $this->get($key);
        if ($tmp = $cursor->getNext()) {

            return $tmp['created_at'];
        }

        return false;
    }

    /**
     * Remove a cache record
     *
     * @param  string $id Cache id
     * @return boolean True if no problem
     */
    public function delete($key, $exp = 0) {
        /**
         * If expires immediately then just remove data
         * otherwise set expiration time.
         */
        if (0 === $exp) {

            return $this->_collection->remove(array('_id' => $key));
        }

        /**
         * If key exists then just change the 'exp' value
         * and re-save it
         */
        $ret = $this->_collection->findOne(array('_id' => $key));
        if (!empty($ret)) {
            $ret['exp'] = $exp;
            $this->_collection->save($ret);
        }

        return true;
    }

    /**
     * Maintainance function to remove expired entries
     * This should be run from special script
     * that instatiates this object and calls
     * this method periodically via cron
     *
     */
    public function clean() {
        return $this->_collection->remove(array('$where' => new MongoCode('function() { return this.exp < ' . (time() - 1) . '; }')));
    }

    /**
     * Return an array of stored cache ids
     *
     * @return array array of stored cache ids (string)
     */
    public function getIds() {
        $cursor = $this->_collection->find();
        $ret = array();
        while ($tmp = $cursor->getNext()) {
            $ret[] = $tmp['_id'];
        }
        return $ret;
    }

    /**
     * Removes entire collection from db
     * this equals to flushing cache completely
     * all keys/values are gone after this call
     */
    public function flush() {
        $dropped = $this->_collection->drop();
        d('dropped: ' . print_r($dropped, 1));

        return true;
    }

    /**
     * Return an array of everying
     * that is stored under this key, not just the value
     * but also the extra fields
     * 'created' and 'exp' (expiration)
     * the actual data is in the 'd' key
     *
     * @param string $id cache id
     * @return mixed null if not found
     * or array
     */
    public function getRawData($key) {
        $ret = $this->_collection->findOne(array('_id' => $key));

        return (empty($ret)) ? null : $ret;
    }

    /**
     * Set item into cache
     * @param string $key cache key
     * @param mixed $value array|string|object|int|bool
     * @param int $ttl expiration time either as unix timestamp
     * or numer of seconds, in case of number of seconds, it cannot
     * be > 2592000 (30 days)
     */
    public function set($key, $value, $ttl = 0, array $tags = null) {
        d('setting key ' . $key);

        if (is_resource($value)) {
            throw new \InvalidArgumentException('Cannot set resource into cache. Only serializable object, array, string, int, double or bool can be set as value');
        }

        $ttl = (int) $ttl;
        $now = time();
        $isSerialized = false;

        /**
         * If $ttl is > 2592000 (30 days)
         * then we assume its in a unix timestamp
         * but then must check that expiration is
         * larger than current timestamp
         */
        if ($ttl > 2592000) {
            if ($now > $ttl) {
                throw new \InvalidArgumentException('Value of $ttl is invalid. Must be < 2592000 or > unix timestamp of now');
            }

            $exp = $ttl;
        } else {
            /**
             * Special case: if $ttl is 0 this means
             * 'never expires', so we just
             * set it as 0
             */
            $exp = ($ttl > 0) ? $now + (int) $ttl : 0;
        }

        if (is_object($value)) {
            d('setting object ' . get_class($value));

            if (!($value instanceof \Serializable)) {
                $err = 'Object ' . get_class($value) . ' does not implement Serializable interface';
                d($err);

                throw new \InvalidArgumentException($err);
            }

            $value = serialize($value);
            $isSerialized = true;
            d('serialized object: ' . $value);
        } elseif ($this->bCompress && is_array($value)) {

            $value = \serialize($value);
            $isSerialized = true;
        }

        d('cp');
        if ($this->bCompress) {
            d('cp');
            $data = new \MongoBinData(gzdeflate($value));
        } else {
            d('cp');
            $data = $value;
        }

        d('cp');
        $aData = array('_id' => $this->nameSpace . $key,
            'd' => $data,
            'created' => $now,
            'exp' => $exp);

        d('aData: ' . print_r($aData, true));
        if ($isSerialized) {
            $aData['s'] = true;
        }

        if ($tags) {
            $aData['tags'] = $tags;
        }

        $res = $this->_collection->save($aData);

        d('res: ' . $res);

        return $res;
    }

    /**
     * Add item to cache but only if it does not already exist
     *
     * @param string $key
     * @param mixed $value
     * @param int $ttl
     */
    public function add($key, $value, $ttl = 0) {
        
    }

    public function setMulti(array $aItems, $ttl = 0) {

        foreach ($aItems as $key => $item) {
            $this->set($key, $item, $ttl);
        }

        return true;
    }

    /**
     * @param string $key cache key
     *
     * @return mixed string | false if data with this key does not exist
     * If value is found but is expired, then the item is removed from cache
     * as a way to do mainainance without relying on cron job
     * and false is returned
     */
    public function get($key) {
        $key = $this->nameSpace . $key;
        d('looking for key: ' . $key);
        d('in collection: ' . $this->_collection->getName() . ' in DB: ' . $this->_collection->db);
        $ret = $this->_collection->findOne(array('_id' => $key));
        d('ret: ' . print_r($ret, 1));
        if (empty($ret)) {
            d('not found ' . $key);

            return false;
        }

        return $this->getData($ret);
    }

    /**
     * Get array of requested keys
     * Rather than getting each key individually
     * from inside the loop, we are going to use the
     * MongoDB find() functions which is just one call to mongo
     * vs multiple findOne() calls
     * should be a bit faster
     * @param array $aKeys
     * @return array array of found key=>value pairs
     */
    public function getMulti(array $aKeys) {
        /**
         * Prepend namespace to every key
         * @var unknown_type
         */
        $a = array();
        foreach ($aKeys as $val) {
            $a[] = $this->nameSpace . $val;
        }

        $cursor = $this->_collection->find(array('_id' => array('$in' => $a)));

        $ret = array();
        if ($cursor && ($cursor->count() > 0)) {
            d('cp cursor: ' . gettype($cursor) . ' count: ' . $cursor->count());
            $len = strlen($this->nameSpace);

            while ($tmp = $cursor->getNext()) {

                if ($tmp && is_array($tmp)) {
                    d('cp');
                    /**
                     * If key is found but is expired then we delete it
                     * and it will not be included in returned array
                     */
                    if (false !== $data = $this->getData($tmp)) {

                        /**
                         * Remove the namespace part of the key
                         * so that the returned array will
                         * have the same name of keys as
                         * in the original array
                         */
                        $key = substr($tmp['_id'], $len);
                        $ret[$key] = $data;
                    }
                }
            }
        }

        return $ret;
    }

    /**
     * Decode the value and return it
     * @param array $a
     * array represents one Mongo Document (one row)
     *
     * @return false if cached document is considered expired
     * or if there is no value found at all.
     * Also if document is found to be expired, it
     * is immediately deleted from Mongo collection
     *
     * If found and not expired then the data is returned.
     * If data is determied to be in gzipped format, it is
     * uncompressed before it is returned
     */
    protected function getData(array $a) {

        if ((0 !== $a['exp']) && ((time() - 1) > $a['exp'])) {
            d('going to delete this key ' . $a['_id']);

            $this->delete($a['_id']);

            return false;
        } else {

            $val = $a['d'];

            if (empty($val)) {
                d('cp');
                return false;
            }

            /**
             * If Data is of type MongoBinData
             * then it's a gzipped compressed format
             */
            if ($val instanceof \MongoBinData) {
                $val = gzinflate($val);
            }
        }
        d('cp');

        return (!empty($a['s'])) ? unserialize($val) : $val;
    }

    /**
     * Increment numeric value of $key
     *
     * @param $key
     * @param $int
     * @return unknown_type
     */
    public function increment($key, $int = 1) {
        $ret = $this->_collection->findOne(array('_id' => $key));
        if (!empty($ret) && is_numeric($ret['d'])) {
            $ret['d'] = (int) $ret['d'] + 1;

            $this->_collection->save($ret);
        }

        return true;
    }

    /**
     * Decrement numeric value of $key
     * @param string $key
     * @param int $int
     */
    public function decrement($key, $int = 1) {
        $ret = $this->_collection->findOne(array('_id' => $key));
        if (!empty($ret) && is_numeric($ret['d'])) {
            $ret['d'] = (int) $ret['d'] - 1;

            $this->_collection->save($ret);
        }

        return true;
    }

    public function __toString() {
        return 'MongoDB powered cache driver';
    }

}
